spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import { ExternalLink } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { notFound } from 'next/navigation';5import { t, tOpt } from '@/i18n';6import { isNotBuilt, isNotFound } from '@/lib/api';7import { apiExplore } from '@/lib/api-explore';8import { cn } from '@/lib/cn';9import { compact, formatDate, grouped } from '@/lib/format';10import { routes } from '@/lib/site';11import type { ImportRun, SourceIndicatorRow, SourceResponse } from '@/lib/types-explore';12import { DataTable, type DataTableColumn } from '@/components/data/data-table';13import { NotBuiltState } from '@/components/data/empty-state';14import { FreshnessBadge } from '@/components/data/freshness-badge';15import { Section } from '@/components/data/section';16import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';17import { FooterCredits } from '@/components/layout/site-footer';1819export const revalidate = 900;2021type Params = { id: string };2223async function load(id: string): Promise<SourceResponse | 'not-built' | null> {24 try {25 return await apiExplore.source(id, 60);26 } catch (e) {27 if (isNotFound(e)) return null;28 if (isNotBuilt(e)) return 'not-built';29 throw e;30 }31}3233export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {34 const { id } = await params;35 const data = await load(id);36 if (!data || data === 'not-built') return { title: t('source.notFound'), robots: { index: false } };37 const s = data.source;38 const title = t('source.title', { name: s.name ?? s.id });39 const description = t('source.description', { name: s.name ?? s.id, n: s.n_indicators ?? data.indicators.length, licence: s.licence ?? '' });40 return { title, description, alternates: { canonical: routes.source(s.id) }, openGraph: { title: `${title} — ${t('site.name')}`, description, url: routes.source(s.id), type: 'article' } };41}4243function RunStatus({ status }: { status: string | null }) {44 const s = (status ?? '').toLowerCase();45 const label = tOpt(`source.run.${s}`, status ?? t('common.na'));46 return <span className={cn('inline-flex items-center rounded-sm border px-1.5 py-0.5 text-2xs font-medium', s === 'ok' ? 'border-rule text-ink-2' : s === 'partial' ? 'border-warn/40 text-warn' : s === 'failed' || s === 'quarantined' ? 'border-down/40 text-down' : 'border-rule text-ink-3')}>{label}</span>;47}4849function duration(r: ImportRun): string {50 if (!r.started_at || !r.finished_at) return t('common.na');51 const s = (new Date(r.finished_at).getTime() - new Date(r.started_at).getTime()) / 1000;52 if (!Number.isFinite(s) || s < 0) return t('common.na');53 return s < 60 ? `${Math.round(s)} s` : `${Math.round(s / 60)} min`;54}5556export default async function SourcePage({ params }: { params: Promise<Params> }) {57 const { id } = await params;58 const data = await load(id);59 if (data === null) notFound();60 if (data === 'not-built') return <NotBuiltState />;61 const s = data.source;62 const name = s.name ?? s.id;6364 const indCols: DataTableColumn<SourceIndicatorRow>[] = [65 {66 key: 'indicator',67 header: t('source.series.indicator'),68 cell: (r) => (69 <Link href={routes.indicator(r.slug)} className="link-quiet inline-flex min-h-[32px] items-center text-ink">70 {r.name ?? r.slug}71 </Link>72 ),73 },74 { key: 'code', header: t('source.series.code'), cell: (r) => <code className="font-mono text-xs text-ink-2">{[r.dataset, r.series_code].filter(Boolean).join(' · ')}</code> },75 { key: 'priority', header: t('source.series.priority'), numeric: true, cell: (r) => (r.priority != null ? `#${r.priority}` : t('common.na')) },76 { key: 'coverage', header: t('source.series.coverage'), numeric: true, cell: (r) => (r.n_countries != null ? `${grouped(r.n_countries)} · ${r.n_observations != null ? compact(r.n_observations) : ''}` : t('common.na')) },77 { key: 'last', header: t('source.series.lastYear'), numeric: true, cell: (r) => r.last_year ?? t('common.na'), hideOnMobile: true },78 { key: 'status', header: t('source.series.status'), cell: (r) => <RunStatus status={r.last_status} /> },79 ];80 const runCols: DataTableColumn<ImportRun>[] = [81 { key: 'started', header: t('source.runs.started'), cell: (r) => <span className="tnum text-xs">{r.started_at ? `${formatDate(r.started_at)} ${r.started_at.slice(11, 16)}` : t('common.na')}</span> },82 { key: 'dataset', header: t('source.runs.dataset'), cell: (r) => <span className="break-all font-mono text-xs text-ink-2">{r.dataset}</span> },83 { key: 'status', header: t('source.runs.status'), cell: (r) => <RunStatus status={r.status} /> },84 { key: 'rows', header: t('source.runs.rows'), numeric: true, cell: (r) => (r.rows_valid != null ? grouped(r.rows_valid) : t('common.na')) },85 { key: 'warn', header: t('source.runs.warnings'), numeric: true, cell: (r) => (r.warnings != null ? grouped(r.warnings) : t('common.na')), hideOnMobile: true },86 { key: 'err', header: t('source.runs.errors'), numeric: true, cell: (r) => (r.errors ? <span className="text-down">{grouped(r.errors)}</span> : r.errors === 0 ? '0' : t('common.na')), hideOnMobile: true },87 { key: 'dur', header: t('source.runs.duration'), numeric: true, cell: (r) => duration(r), hideOnMobile: true },88 ];8990 return (91 <>92 <PageHeader93 crumbs={[{ href: routes.sources(), label: t('sources.title') }]}94 eyebrow={s.organization}95 title={name}96 lede={s.attribution}97 meta={98 <>99 <code className="font-mono">{s.id}</code>100 {s.licence ? ` · ${s.licence}` : ''}101 {s.n_indicators != null ? ` · ${t('sources.indicators', { n: grouped(s.n_indicators) })}` : ''}102 {s.n_observations != null ? ` · ${t('sources.observations', { n: compact(s.n_observations) })}` : ''}103 </>104 }105 actions={106 s.url ? (107 <a href={s.url} target="_blank" rel="noopener noreferrer" className={ACTION_CLS}>108 <ExternalLink size={14} aria-hidden /> {t('source.open', { name })}109 </a>110 ) : null111 }112 />113114 <section aria-label={t('source.freshness.title')} className="border-y border-rule">115 <dl className="grid grid-cols-1 gap-y-3 py-4 sm:grid-cols-3 sm:divide-x sm:divide-rule">116 <div className="sm:pr-4">117 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.sourceUpdated')}</dt>118 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>119 </div>120 <div className="sm:px-4">121 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.retrieved')}</dt>122 <dd className="tnum mt-1 flex flex-wrap items-center gap-2 text-base font-semibold text-ink">123 {formatDate(s.last_success_at ?? data.freshness.retrieved_at)} <FreshnessBadge retrievedAt={s.last_success_at ?? data.freshness.retrieved_at} />124 </dd>125 </div>126 <div className="sm:pl-4">127 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('source.freshness.built')}</dt>128 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.built_at)}</dd>129 </div>130 </dl>131 </section>132133 <Section id="about" title={t('source.about')} className="border-t-0" tight>134 <dl className="grid gap-x-8 gap-y-2 text-sm sm:grid-cols-2">135 <div>136 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.organisation')}</dt>137 <dd className="text-ink">{s.organization ?? t('common.na')}</dd>138 </div>139 <div>140 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.licence')}</dt>141 <dd className="text-ink">{s.licence ?? t('common.na')}</dd>142 </div>143 <div>144 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.attribution')}</dt>145 <dd className="text-ink">{s.attribution ?? t('common.na')}</dd>146 </div>147 <div>148 <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('sources.apiBase')}</dt>149 <dd className="break-all font-mono text-xs text-ink">{s.api_base ?? t('common.na')}</dd>150 </div>151 {s.notes ? (152 <div className="sm:col-span-2">153 <dd className="text-ink-2">{s.notes}</dd>154 </div>155 ) : null}156 </dl>157 </Section>158159 {data.datasets.length ? (160 <Section id="datasets" title={t('source.datasets.title')} subtitle={t('source.datasets.sub')} tight>161 <DataTable162 rows={data.datasets}163 rowKey={(d) => d.dataset ?? 'null'}164 caption={t('source.datasets.title')}165 columns={[166 { key: 'dataset', header: t('source.datasets.dataset'), cell: (d) => <span className="font-medium text-ink">{d.dataset ?? t('common.na')}</span> },167 { key: 'obs', header: t('source.datasets.observations'), numeric: true, cell: (d) => (d.n_observations != null ? grouped(d.n_observations) : t('common.na')) },168 { key: 'ind', header: t('source.datasets.indicators'), numeric: true, cell: (d) => (d.n_indicators != null ? grouped(d.n_indicators) : t('common.na')) },169 { key: 'cty', header: t('source.datasets.countries'), numeric: true, cell: (d) => (d.n_countries != null ? grouped(d.n_countries) : t('common.na')) },170 { key: 'years', header: t('source.datasets.years'), numeric: true, cell: (d) => (d.first_year && d.last_year ? `${d.first_year}–${d.last_year}` : t('common.na')) },171 ]}172 />173 </Section>174 ) : null}175176 <Section id="series" title={t('source.series.title')} subtitle={t('source.series.sub', { n: grouped(data.indicators.length) })}>177 {data.indicators.length ? <DataTable rows={data.indicators} rowKey={(r) => `${r.slug}-${r.series_code}`} columns={indCols} caption={t('source.series.title')} dense /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}178 </Section>179180 <Section id="runs" title={t('source.runs.title')} subtitle={t('source.runs.sub', { n: grouped(data.import_runs.length) })}>181 {data.import_runs.length ? <DataTable rows={data.import_runs} rowKey={(r) => `${r.run_id}-${r.dataset}`} columns={runCols} caption={t('source.runs.title')} dense /> : <p className="text-sm text-ink-3">{t('source.runs.none')}</p>}182 </Section>183184 <div className="border-t border-rule pt-4 text-sm text-ink-2">185 <span className="mr-2 font-medium text-ink">{t('sources.contact')}</span>186 <FooterCredits className="mt-1 inline text-xs" />187 </div>188 </>189 );190}191